刷新 token 使用源码解析

依赖版本
PIGX5.6
架构模式微服务
sequenceDiagram
    participant UI as pigx-ui
    participant Gateway as pigx-gateway
    participant Auth as pigx-auth
    participant Redis as Redis

    UI->>UI: 定时检查 Token 有效期
    UI->>UI: 有效期 ≤ 30 分钟,触发刷新
    UI->>Gateway: POST /auth/oauth2 /token<br/>grant_type=refresh_token
    Gateway->>Auth: 路由转发请求
    Auth->>Auth: 客户端认证(Basic Auth)
    Auth->>Auth: RefreshTokenConverter 解析请求
    Auth->>Redis: 根据 refresh_token 查找授权信息
    Redis-->>Auth: 返回 OAuth2Authorization
    Auth->>Auth: 验证 refresh_token 有效性
    Auth->>Auth: 生成新 access_token + refresh_token
    Auth->>Redis: 存储新的 Token 授权信息
    Auth-->>Gateway: 返回新 Token
    Gateway-->>UI: 返回新 Token
    UI->>UI: 更新本地存储

前端 Token 有效期检查

前端通过定时轮询 checkToken() 方法检查当前 access_token 的有效期,当剩余有效期不足 30 分钟时自动触发刷新。

// Token 刷新锁,防止并发刷新
const tokenRefreshLock = refAutoReset(false, 100);

async function checkToken(): Promise<boolean> {
    // 1. 调用后端检查 Token 有效期
    const response = await request.get('/auth/token/check_token', { token });

    // 2. 计算剩余有效期
    const expiredPeriod = Date.parse(response.data.expiresAt) - Date.now();
    const HALF_HOUR = 30 * 60 * 1000;

    // 3. 小于 30 分钟且未在刷新中,触发续期
    if (expiredPeriod <= HALF_HOUR && !tokenRefreshLock.value) {
        tokenRefreshLock.value = true;
        await useUserInfo().refreshToken();
        tokenRefreshLock.value = false;
    }
}
防并发机制
使用 VueUse 的 refAutoReset 实现刷新锁,100ms 后自动重置为 false,避免多个请求同时触发刷新。

前端组装刷新请求

checkToken() 判断需要刷新时,通过 Pinia Store 调用 refreshTokenApi 发起刷新请求。

// 刷新 Token API
function refreshTokenApi(refresh_token: string) {
    return request.post('/auth/oauth2/token', {
        params: { refresh_token, grant_type: 'refresh_token', scope: 'server' },
        headers: {
            Authorization: Session.get('basicAuth'),  // 客户端 Basic 认证
            'Content-Type': 'application/x-www-form-urlencoded',
        },
    });
}

// Pinia Store 刷新 Token
async refreshToken() {
    const refreshToken = Session.get('refresh_token');
    const res = await refreshTokenApi(refreshToken);
    // 更新本地存储
    Session.set('token', res.access_token);
    Session.set('refresh_token', res.refresh_token);
}

刷新请求报文示例:

POST /auth/oauth2/token?grant_type=refresh_token&refresh_token=xxx&scope=server HTTP/1.1
Host: pig-gateway:9999
Authorization: Basic dGVzdDp0ZXN0
Content-Type: application/x-www-form-urlencoded

网关路由转发

路由转发
所有以 /auth 开头的请求会被网关自动转发至 pigx-auth 服务,并截取路由前缀。详细说明参考登录 Token 生成源码解析
http://127.0.0.1:9999/auth/oauth2/token
转发到 pigx-auth 的请求路径自动截取前缀变成
http://127.0.0.1:3000/oauth2/token

客户端认证

与登录流程一致,刷新请求中会携带 Basic base64(clientId:clientSecret)OAuth2ClientAuthenticationFilter 通过 RegisteredClientRepository(数据库存储)来校验客户端凭证。

客户端一致性
刷新请求的 Basic Auth 必须与登录时使用的客户端一致,前端通过 Session.get('basicAuth') 获取登录时缓存的客户端认证信息。

请求转换:PigxOAuth2RefreshTokenAuthenticationConverter

pigx-auth 自定义了 PigxOAuth2RefreshTokenAuthenticationConverter 覆盖原生实现,解决了原生 Converter 无法获取 query params 的问题。

// 覆盖原生实现,支持 query params 获取
public class PigxOAuth2RefreshTokenAuthenticationConverter implements AuthenticationConverter {

    public Authentication convert(HttpServletRequest request) {
        // 1. 从 query + body 中统一获取参数
        MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);

        // 2. 校验 grant_type 是否为 refresh_token
        String grantType = parameters.getFirst("grant_type");
        if (!"refresh_token".equals(grantType)) return null;

        // 3. 校验 refresh_token 参数(必填)
        String refreshToken = parameters.getFirst("refresh_token");
        ...

        // 4. 解析 scope(可选,可重新指定权限范围)
        Set<String> requestedScopes = parseScopes(parameters);

        // 5. 组装认证对象
        return new OAuth2RefreshTokenAuthenticationToken(
            refreshToken, clientPrincipal, requestedScopes, additionalParameters);
    }
}
覆盖原因
原生 OAuth2RefreshTokenAuthenticationConverter 仅从 request body 获取参数,而前端通过 query params 传递 refresh_token,因此需要自定义实现以兼容两种传参方式。

该 Converter 在 AuthorizationServerConfiguration 中注册:

public AuthenticationConverter accessTokenRequestConverter() {
    return new DelegatingAuthenticationConverter(Arrays.asList(
        new OAuth2ResourceOwnerPasswordAuthenticationConverter(),  // 密码模式
        new OAuth2ResourceOwnerSmsAuthenticationConverter(),       // 短信模式
        new PigxOAuth2RefreshTokenAuthenticationConverter(),       // 刷新 Token
        new OAuth2ClientCredentialsAuthenticationConverter(),      // 客户端模式
        new OAuth2AuthorizationCodeAuthenticationConverter(),      // 授权码模式
        ...
    ));
}

核心认证:OAuth2RefreshTokenAuthenticationProvider

OAuth2RefreshTokenAuthenticationProvider 是 Spring Authorization Server 内置的刷新 Token 认证提供者,负责核心的刷新逻辑。

// Spring Authorization Server 内置实现
public class OAuth2RefreshTokenAuthenticationProvider implements AuthenticationProvider {

    public Authentication authenticate(Authentication authentication) {
        OAuth2RefreshTokenAuthenticationToken refreshTokenAuth = (OAuth2RefreshTokenAuthenticationToken) authentication;

        // 1. 根据 refresh_token 值查找授权信息
        OAuth2Authorization authorization = authorizationService.findByToken(
            refreshTokenAuth.getRefreshToken(), OAuth2TokenType.REFRESH_TOKEN);
        ...

        // 2. 验证客户端是否支持 refresh_token 授权模式
        RegisteredClient registeredClient = ...;
        if (!registeredClient.getAuthorizationGrantTypes()
                .contains(AuthorizationGrantType.REFRESH_TOKEN)) {
            throw new OAuth2AuthenticationException(...);
        }

        // 3. 生成新的 access_token
        OAuth2AccessToken accessToken = tokenGenerator.generate(tokenContext);

        // 4. 生成新的 refresh_token(如果启用了轮转)
        OAuth2RefreshToken refreshToken = tokenGenerator.generate(refreshTokenContext);

        // 5. 返回认证结果
        return new OAuth2AccessTokenAuthenticationToken(
            registeredClient, clientPrincipal, accessToken, refreshToken, additionalParameters);
    }
}
Refresh Token 查找
PIGX 使用 Redis 存储 Token,authorizationService.findByToken() 会从 Redis 中按 key token::refresh_token::{value} 查找对应的 OAuth2Authorization 对象。

Token 生成器配置

AuthorizationServerConfiguration 中配置了 Token 生成器,由 DelegatingOAuth2TokenGenerator 委派给不同类型的生成器。

@Bean
public OAuth2TokenGenerator oAuth2TokenGenerator() {
    CustomeOAuth2AccessTokenGenerator accessTokenGenerator = new CustomeOAuth2AccessTokenGenerator();
    // 注入 Token 增加关联用户信息
    accessTokenGenerator.setAccessTokenCustomizer(new CustomeOAuth2TokenCustomizer());
    return new DelegatingOAuth2TokenGenerator(accessTokenGenerator, new OAuth2RefreshTokenGenerator());
}
flowchart LR
    A[DelegatingOAuth2TokenGenerator] --> B[CustomeOAuth2AccessTokenGenerator]
    A --> C[OAuth2RefreshTokenGenerator]
    B --> D[生成 access_token]
    C --> E[生成 refresh_token]

Token 有效期通过 PigxRemoteRegisteredClientRepository 中的客户端配置确定:

配置项默认值说明
accessTokenTimeToLive12 小时访问令牌有效期
refreshTokenTimeToLive30 天刷新令牌有效期
// 客户端 Token 配置
TokenSettings.builder()
    .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
    .accessTokenTimeToLive(Duration.ofSeconds(accessTokenValidity))   // 默认 12 小时
    .refreshTokenTimeToLive(Duration.ofSeconds(refreshTokenValidity)) // 默认 30 天
    .build()
有效期可配置
每个客户端可通过 sys_oauth_client_details 表的 access_token_validityrefresh_token_validity 字段单独配置有效期。

Redis Token 存储更新

刷新成功后,PigxRedisOAuth2AuthorizationService 负责将新的授权信息存储到 Redis,同时清理旧的 Token。

// Redis Token 存储
public class PigxRedisOAuth2AuthorizationService implements OAuth2AuthorizationService {

    public void save(OAuth2Authorization authorization) {
        // 1. 存储 refresh_token
        if (isRefreshToken(authorization)) {
            OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
            long ttl = ChronoUnit.SECONDS.between(refreshToken.getIssuedAt(), refreshToken.getExpiresAt());
            redisTemplate.opsForValue().set(
                "token::refresh_token::" + refreshToken.getTokenValue(),
                authorization, ttl, TimeUnit.SECONDS);
        }

        // 2. 存储 access_token
        if (isAccessToken(authorization)) {
            OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
            long ttl = ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt());
            redisTemplate.opsForValue().set(
                "token::access_token::" + accessToken.getTokenValue(),
                authorization, ttl, TimeUnit.SECONDS);

            // 3. 维护 username 与 access_token 的关系索引
            String tokenUsername = "token::username::" + username + "::" + clientId
                + "::" + tenantId + "::" + accessToken.getTokenValue();
            redisTemplate.opsForValue().set(tokenUsername, accessToken.getTokenValue(), ttl, TimeUnit.SECONDS);
        }
    }
}

Redis 中的 Key 结构:

Key 格式TTL说明
token::refresh_token::{value}refresh_token 有效期刷新令牌索引
token::access_token::{value}access_token 有效期访问令牌索引
token::username::{name}::{clientId}::{tenantId}::{value}access_token 有效期用户令牌关系索引

认证成功响应

PigxAuthenticationSuccessEventHandler 处理认证成功后的响应输出,将新的 Token 写入 HTTP 响应。

// 认证成功处理器
public class PigxAuthenticationSuccessEventHandler implements AuthenticationSuccessHandler {

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) {
        OAuth2AccessTokenAuthenticationToken tokenAuth = (OAuth2AccessTokenAuthenticationToken) authentication;
        OAuth2AccessToken accessToken = tokenAuth.getAccessToken();
        OAuth2RefreshToken refreshToken = tokenAuth.getRefreshToken();

        // 组装响应
        OAuth2AccessTokenResponse.Builder builder = OAuth2AccessTokenResponse
            .withToken(accessToken.getTokenValue())
            .tokenType(accessToken.getTokenType())
            .scopes(accessToken.getScopes())
            .expiresIn(ChronoUnit.SECONDS.between(accessToken.getIssuedAt(), accessToken.getExpiresAt()));

        if (refreshToken != null) {
            builder.refreshToken(refreshToken.getTokenValue());  // 返回新的 refresh_token
        }

        // 无状态,清除 SecurityContext
        SecurityContextHolder.clearContext();
        accessTokenHttpResponseConverter.write(builder.build(), null, httpResponse);
    }
}

响应报文示例:

{
    "access_token": "新的访问令牌",
    "refresh_token": "新的刷新令牌",
    "token_type": "Bearer",
    "expires_in": 43200,
    "scope": "server"
}

前端存储更新

前端收到新 Token 后,通过 Session 工具类更新本地存储,采用 SessionStorage + Cookies 双重存储策略。

// 更新本地 Token 存储
Session.set('token', res.access_token);
Session.set('refresh_token', res.refresh_token);

// Session 内部实现:双重存储
function set(key: string, val: any) {
    if (key === 'token' || key === 'refresh_token') {
        Cookies.set(key, val);  // 持久化到 Cookie
    }
    window.sessionStorage.setItem(key, JSON.stringify(val));  // 存入 SessionStorage
}
双重存储策略
Token 同时存储在 SessionStorage 和 Cookies 中,SessionStorage 用于当前会话的快速读取,Cookies 用于跨标签页的持久化。

Token 过期兜底处理

当 refresh_token 也过期或刷新失败时,后端返回 424 状态码,前端响应拦截器会引导用户重新登录。

// 响应拦截器 - Token 过期兜底
service.interceptors.response.use(handleResponse, (error) => {
    const status = error.response.status;

    if (status === 424) {
        // Token 完全过期,引导重新登录
        MessageBox.confirm('令牌状态已过期,请点击重新登录').then(() => {
            Session.clear();
            window.location.href = '/';
        });
    }
});
424 与自动刷新的关系
正常情况下 checkToken() 会在 access_token 到期前 30 分钟自动刷新,424 是最终兜底机制,仅在 refresh_token 也失效或刷新异常时触发。